# vBulletin BBCode unparser
# by Cee Pee

import re
import forumapi
from deps import BeautifulSoup

## Exceptions ##

class BBCodeError(Exception):
	"""Indicates a failure during parsing of BBCode or data collection."""

## Utilities ##

def bbcode_unparse(self, session, soup):
	"""Immediately unparses an HTML representation of BBCode, and returns the
	textual BBCode."""
	
	_setup_unparser(session)
	return(session.site.bbcode_unparser.unparse(soup))

def _setup_unparser(session):
	"""Adds a new BBCode unparser to the session's Site object, if it did not
	yet exist."""
	
	if not hasattr(session.site, "bbcode_unparser"):
		if session.bbcode_file:
			data = forumapi.unpickle_data(session.bbcode_file)
		else:
			data = {}
		
		session.site.bbcode_unparser = Unparser(session, **data)

## Hooks ##

def _session_close(session=None, **kwargs):
	if session.bbcode_file and hasattr(session.site, "bbcode_unparser") and session.site.bbcode_unparser.has_data:
		forumapi.pickle_data(session.bbcode_file, session.site.bbcode_unparser.data)

forumapi.hooks.add("session_close", _session_close)

## Classes ##

class BBCode(forumapi.Mergeable, forumapi.Data):
	def __init__(self, session, soup):
		self.session = session
		self.soup = soup
		
		self._text = None
		
		forumapi.hooks.run("vb_bbcode_init", **locals())
	
	def merge(self, other):
		if isinstance(other, BBCode):
			if other._text is not None:
				self._text = other._text
		elif isinstance(other, str):
			self._text = other
	
	@property
	def data(self):
		return(self._text)
	
	def unparse(self):
		"""Unparses the HTML."""
		
		_setup_unparser(self.session)
		
		self._text = self.session.site.bbcode_unparser.unparse(self.soup, self)
	
	@property
	def text(self):
		if self._text is not None:
			return(self._text)
		else:
			self.unparse()
			return(self._text)
	
	def __eq__(self, other):
		return(self.soup == other.soup)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return(self.text)
	
	def __len__(self):
		return(len(self.text))
	
	def __repr__(self):
		return("'{0}'#bb".format(self.__str__()))

class Unparser(forumapi.Data):
	def __init__(self, session, tags=None, smilies=None):
		self.session = session
		
		if isinstance(tags, list):
			self._tags = [Tag(unparser=self, **tag) for tag in tags]
		else:
			self._tags = None
		
		self._tags_soup = None
		
		if isinstance(smilies, list):
			self._smilies = [Smilie(**smilie) for smilie in smilies]
		else:
			self._smilies = None
		
		self._noparse_pattern = ""
		
		self.context = None
	
	def unparse(self, soup, context=None):
		"""Recursively unparses a `BeautifulSoup` (or a `Tag` with `contents`,
		or a string, which is converted to soup)."""
		
		if isinstance(soup, BeautifulSoup.NavigableString):
			return(self.do_noparse(soup))
		
		self.context = context
		
		try:
			if isinstance(soup, str):
				soup = BeautifulSoup.BeautifulSoup(soup)
			
			if isinstance(soup, BeautifulSoup.BeautifulStoneSoup) and len(soup.contents) == 1:
				# Use root tag
				soup = soup.contents[0]
			
			text = self._unparse(soup)
		finally:
			self.context = None
		
		return(text)
	
	def _unparse(self, soup):
		text = ""
		i = 0
		while hasattr(soup, "contents") and i < len(soup.contents):
			item = soup.contents[i]
			if isinstance(item, BeautifulSoup.NavigableString):
				text += self.do_noparse(item)
			elif hasattr(item, "name") and item.name == "br":
				text += "\n"
			else:
				try:
					t = self.tags.index(item)
					i += self.tags[t].last_items
					
					text += self.substitute(self.tags[t])
					continue
				except:
					pass
				
				if hasattr(item, "name") and item.name == "img":
					try:
						s = self.smilies.index(item)
						text += self.smilies[s].replacement
					except ValueError:
						pass
			
			i += 1
		
		return(text)
	
	_noparse_repl = "[noparse]\g<0>[/noparse]"
	def do_noparse(self, soup):
		"""Wraps BBCode that wasn't parsed in a NavigableString inside
		[noparse] tags, returning a string."""
		
		if soup.string is None:
			return("")
		
		text = str(soup.string).strip("\t\r\n")
		
		# Don't get the BBCode list if we don't need it
		if text.find("[") and text.find("]"):
			return(re.sub(self.noparse_pattern, self._noparse_repl, text, flags=re.S))
		else:
			return(text)
	
	def substitute(self, tag):
		"""Substitutes a `PageElement` (of which replacement matches can be
		found in `tag.last_match`) with the BBCode of a `tag` whose template
		matched it."""
		
		text = "[" + tag.tag
		if tag.option:
			text += "=" + str(tag.last_match["option"])
		
		text += "]"
		
		if hasattr(tag.last_match["value"], "contents"):
			text += self.unparse(tag.last_match["value"])
		else:
			text += str(tag.last_match["value"])
		
		text += "[/" + tag.tag + "]"
		
		return(text)
	
	def fix_tags(self):
		"""Manually fixes a few templates."""
		
		for tag in self.tags[:]:
			if tag.name == "Images":
				replacement = AttrReplacement(tag, "value")
				image = BeautifulSoup.Tag(self.tags_soup, "img", None, parent=tag.template, previous=tag.template)
				image.attrs = [("src", replacement), ("border", "0"), ("alt", "")]
				tag.template.contents = [image]
			elif tag.name == "Code":
				pre = tag.template.find("pre")
				pre.contents[0] = SoupReplacement(tag, "value", parent=pre, previous=pre)
	
	# List of names on the BB Code List which we won't unparse for now
	# There are also others that will error out on other problems
	_autolist_blacklist = [
		"Bulleted Lists", "Advanced Lists",
		"PHP Code", "HTML Code",
	]
	def get_tags(self):
		self._tags = []
		
		tables = self.tags_soup.findAll("table", "tborder")
		
		for table in tables:
			try:
				rows = table.findAll("tr")
				
				if len(rows) == 5:
					tags = self.get_usage_tags(rows)
					print(tags)
					
					self.parse_example_usage(tags, rows)
					
					self.parse_example_output(tags, rows)
					
					self._tags.extend(tags)
			except BBCodeError as error:
				print("ERROR: {0}".format(error))
		
		self.fix_tags()
		
		forumapi.hooks.run("vb_bbcode_tags", **locals())
		
		# Reverse the tags
		self._tags.reverse()
		
		self._noparse_pattern = "\[(?P<tag>"
		self._noparse_pattern += "|".join([re.escape(tag.tag) for tag in self.tags])
		self._noparse_pattern += ")(?:=[^\]]+)?\].*?\[/(?P=tag)\]"
	
	def get_usage_tags(self, rows):
		cat_cell = rows[0].find("td", "tcat")
		
		name = str(cat_cell.contents[0])
		if name in self._autolist_blacklist:
			# Not supported for now
			raise(BBCodeError("BBCode with name '{0}' not supported".format(name)))
		
		tags = []
		
		usage = rows[2].findAll("td")[1]
		
		tag = ""
		option = False
		expecting = "tag"
		
		for item in usage.contents:
			if hasattr(item, "name"):
				if item.name == "br":
					tags.append(Tag(name, tag, option, unparser=self))
					tag = ""
					option = False
					expecting = "tag"
					continue
				elif item.name == "span" and item.get("class") == "highlight":
					if expecting == "option":
						option = True
						expecting = "rest"
			elif expecting == "tag":
				tag_str = str(item.string).strip()
				if tag_str[-1] == "=":
					expecting = "option"
				else:
					expecting = "rest"
				
				tag = tag_str[1:-1]
		
		if tag:
			tags.append(Tag(name, tag, option, unparser=self))
		
		return(tags)
	
	def parse_example_usage(self, tags, rows):
		ex_usage = rows[3].findAll("td")[1].contents
		u = 0
		
		for tag in tags:
			search = ""
			for u in range(u, len(ex_usage)):
				item = ex_usage[u]
				if isinstance(item, BeautifulSoup.NavigableString):
					search += str(item.string).strip("\t\r\n") + "\n"
					if search.endswith("[/" + tag.tag + "]\n"):
						break
			
			u += 1
			
			pattern = "^(?P<leading>.*?)\[" + tag.tag
			if tag.option:
				pattern += "=(?P<option>[^\]]*)"
			pattern += "\](?P<value>.*?)\[/" + tag.tag + "\](?P<trailing>.*?)\n?$"
			
			match = re.match(pattern, search, re.S)
			if match:
				tag.ex_groups = match.groupdict()
			else:
				raise(BBCodeError("No match in example usage for [{0}]".format(tag.tag)))
			
			if tag.option and "option" not in tag.ex_groups:
				raise(BBCodeError("Found option in usage for [{0}] but the example usage didn't contain one".format(tag.tag)))
	
	def parse_example_output(self, tags, rows):
		ex_output = rows[4].findAll("td")[1]
		
		for tag in tags:
			if len(ex_output.contents) == 0:
				raise(BBCodeError("Example output for [{0}] is empty".format(tag.tag)))
			
			has_tags = False
			for item in ex_output.contents:
				if isinstance(item, BeautifulSoup.Tag):
					has_tags = True
					break
			
			if not has_tags:
				raise(BBCodeError("Example output for [{0}] does not contain any tags".format(tag.tag)))
			
			if len(tags) == 1:
				template = ex_output
				del(template["class"])
				template.name = "template"
			else:
				template = BeautifulSoup.Tag(self.tags_soup, "template")
				template.setup(parent=ex_output.parent)
				for item in ex_output.contents[:]:
					item.extract()
					if hasattr(item, "name") and item.name == "br":
						break
					
					template.insert(len(template.contents), item)
			
			self.remove_whitespace(tag.tag, template, 0)
			self.remove_whitespace(tag.tag, template, -1)
			
			self.replace_recursive(template, tag)
			tag.template = template
	
	def remove_whitespace(self, tag, template, index):
		"""Extracts whitespace at a given index from a tag's template soup in
		order to have it match with whitespaceless uses of the tag."""
		
		try:
			if isinstance(template.contents[index], BeautifulSoup.NavigableString) and str(template.contents[index]).strip("\t\r\n") == "":
				template.contents[index].extract()
		except:
			raise(BBCodeError("Index not in template for [{0}]".format(tag)))
		
		if len(template.contents) == 0:
			raise(BBCodeError("Template for [{0}] is contentless".format(tag)))
	
	def replace_recursive(self, soup, tag):
		"""Replaces parts of a tag's template soup in order to have it match
		with other uses than the usage example."""
		
		if isinstance(soup, BeautifulSoup.Tag):
			for key, value in soup.attrs[:]:
				new_value = self.replace_attr(value, tag)
				if new_value:
					soup[key] = new_value
			
			for item in soup.contents[:]:
				self.replace_recursive(item, tag)
		else:
			self.replace_soup(soup, tag)
	
	def replace_soup(self, soup, tag):
		"""Replaces parts of a tag's template soup in order to have it match
		with actual uses. Extracts leading and trailing content and replaces
		the value with a `SoupReplacement`."""
		
		text = str(soup).strip("\t\r\n")
		if text != "":
			for type, search in tag.ex_groups.items():
				pos = text.find(search)
				if pos != -1:
					if type == "leading" and search != "":
						# We don't need them in the actual template
						soup.extract()
						break
					elif type == "trailing" and search != "":
						# Stray examples.
						while soup.parent.contents[-1] != soup:
							soup.parent.contents[-1].extract()
						
						soup.extract()
						break
					elif type == "value" or type == "option":
						replacement = SoupReplacement(tag, type, leading=text[0:pos], trailing=text[pos + len(search):])
						soup.replaceWith(replacement)
						break
	
	def replace_attr(self, value, tag):
		"""Retrieves a replacement for a soup Tag's attribute given its value.
		
		Returns an `AttrReplacement` or `None` if the attribute does not have
		to be replaced."""
		
		for type, search in tag.ex_groups.items():
			if type == "value" or type == "option":
				pos = value.find(search)
				if pos != -1:
					return(AttrReplacement(tag, type, leading=value[0:pos], trailing=value[pos + len(search):]))
	
	def get_smilies(self):
		"""Retrieves a list of smilie replacements from the smilie page."""
		
		self._smilies = []
		
		page = self.session.get_page("misc.php?do=getsmilies")
		
		page_soup = self.session.soup(page)
		
		table = page_soup.find(id="smilietable")
		
		rows = table.findAll("tr")
		
		for row in rows:
			cells = row.findAll("td")
			if len(cells) >= 2:
				self.smilies.append(self.get_smilie(cells[0]))
			
			if len(cells) == 4:
				self.smilies.append(self.get_smilie(cells[2]))
	
	def get_smilie(self, image_cell):
		"""Creates a `Smilie` object given a cell soup from the smilie page.
		
		Returns rhe `Smilie` object."""
		
		# We could even go and fetch its ID but this suffices
		image = image_cell.find("img")
		return(Smilie(image["src"], image["title"], image["alt"]))
	
	@property
	def tags(self):
		if self._tags is not None:
			return(self._tags)
		else:
			self.get_tags()
			return(self._tags)
	
	@property
	def tags_soup(self):
		if self._tags_soup is not None:
			return(self._tags_soup)
		else:
			page = self.session.get_page("misc.php?do=bbcode")
			
			self._tags_soup = self.session.soup(page)
			return(self._tags_soup)
	
	@property
	def noparse_pattern(self):
		if self._noparse_pattern:
			return(self._noparse_pattern)
		else:
			self.get_tags()
			return(self._noparse_pattern)
	
	@property
	def smilies(self):
		if self._smilies is not None:
			return(self._smilies)
		else:
			self.get_smilies()
			return(self._smilies)
	
	@property
	def has_data(self):
		return(self._tags is not None and self._smilies is not None)
	
	@property
	def data(self):
		return({
			"tags": forumapi._flatten_data(self._tags),
			"smilies": forumapi._flatten_data(self.smilies),
		})

class Tag(forumapi.Data):
	"""Represents a BBCode tag template."""
	
	def __init__(self, name, tag, option=False, unparser=None, template=None):
		self.name = name
		self.tag = tag
		self.option = option
		self.unparser = unparser
		
		self.ex_groups = None
		
		if isinstance(template, str):
			template = BeautifulSoup.BeautifulSoup(template).find("template")
		
		self.template = template
		
		self.last_items = 1
		self.last_match = {}
	
	@property
	def data(self):
		return({
			"name": self.name,
			"tag": self.tag,
			"option": self.option,
			"template": repr(self.template),
		})
	
	def __eq__(self, other):
		if isinstance(other, Tag):
			return(self.name == other.name and self.tag == other.tag and self.option == other.option)
		elif isinstance(other, BeautifulSoup.Tag):
			if self.template is None:
				return(False)
			
			ret = forumapi.hooks.run("vb_bbcode_tag_eq", Tag, **locals())
			if ret is not None:
				return(ret)
			
			if len(self.template.contents) > 0 and self.template.contents[0] == other.parent.contents[0]:
				index = other.parent.contents.index(other)
				
				# We start at 1 because that makes the indexes look better, and
				# it makes the block statements in BBCodeUnparser.unparse look
				# better.
				i = 1
				try:
					while self.template.contents[i] == other.parent.contents[index + i]:
						i += 1
				except:
					pass
				
				self.last_items = i
				return(True)
			elif len(self.template.contents) == 1 and self.template.contents[0].name == other.name and self.template.contents[0].attrs == other.attrs:
				try:
					for i in range(0, len(self.template.contents[0].contents)):
						if self.template.contents[0].contents[i] != other.contents[i]:
							return(False)
				except:
					# Only return False if `other` is too short, not when it's
					# just not the right length since that can be caught by the
					# replacement
					return(False)
				
				return(True)
			else:
				try:
					for i in range(0, len(self.template.contents)):
						if self.template.contents[i] != other.contents[i]:
							return(False)
				except:
					# Only return False if `other` is too short, not when it's
					# just not the right length since that can be caught by the
					# replacement
					return(False)
				
				return(True)
		elif isinstance(other, str):
			return(self.tag == other)
		else:
			return(False)
	
	def __str__(self):
		return(self.tag)
	
	def __repr__(self):
		return("<Tag [{self.tag}{0}] \"{self.name}\">".format("=option" if self.option else "", self=self))

class Replacement(object):
	def __init__(self, tag, type, leading="", trailing=""):
		self.tag = tag
		self.type = type
		self.leading = leading
		self.trailing = trailing
	
	def match(self, other):
		pattern = "^" + re.escape(self.leading) + "(.*?)" + re.escape(self.trailing) + "$"
		match = re.match(pattern, other, re.S)
		if match:
			self.tag.last_match[self.type] = match.group(0)
			return(True)
		else:
			return(False)
	
	def __eq__(self, other):
		"""Subclasses should extend this to set `self.tag.last_match[self.type]`
		to `other` and return True when it is checked against a value that is
		an instance	of something they're trying to emulate."""
		
		if isinstance(other, Replacement):
			return(self.tag == other.tag and self.type == other.type)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return("{self.leading}%{self.type}%{self.trailing}".format(self=self))
	
	def __repr__(self):
		return("<Replacement \"{self.type}\" for [{self.tag.tag}]>".format(self=self))

class SoupReplacement(Replacement, BeautifulSoup.NavigableString):
	"""Replacement for a `PageElement`."""
	
	def __new__(cls, *a, **kw):
		return(BeautifulSoup.NavigableString.__new__(cls, "<replacement />"))
	
	def __init__(self, tag, type, leading="", trailing="", parent=None, previous=None):
		Replacement.__init__(self, tag, type, leading=leading, trailing=trailing)
		self.setup(parent=parent, previous=previous)
	
	def __eq__(self, other):
		if Replacement.__eq__(self, other):
			return(True)
		elif isinstance(other, BeautifulSoup.NavigableString):
			return(self.match(other))
		elif isinstance(other, BeautifulSoup.PageElement):
			# TODO: Verify this works
			print(str(other.decode()))
			#index = other.parent.contents.index(other)
			#if index < len(other.parent.contents) - 1:
			#	print(other.parent.contents)
			#	match = other.parent
			#	for i in range(0, index):
			#		match.contents[i].extract()
			#	
			#	self.tag.last_match[self.type] = match
			#else:
			#	print(str(other.parent.decode()))
			#	self.tag.last_match[self.type] = other.parent
			if self.leading == "" and self.trailing == "":
				self.tag.last_match[self.type] = other.parent
				#other.extract()
				return(True)
			else:
				return(False)
		else:
			return(False)
	
	def decodeGivenEventualEncoding(self, eventualEncoding):
		return(Replacement.__str__(self))
		#return("<replacement type=\"{self.type}\" />".format(self=self))

#class ListReplacement(Replacement, BeautifulSoup.Tag):

class AttrReplacement(Replacement):
	"""Replacement for an attribute string."""
	
	def __eq__(self, other):
		if super().__eq__(other):
			return(True)
		elif isinstance(other, str):
			return(self.match(other))
		else:
			return(False)

class Smilie(forumapi.Data):
	def __init__(self, url, title, replacement):
		self.url = url
		self.title = title
		self.replacement = replacement
	
	@property
	def data(self):
		return({
			"url": self.url,
			"title": self.title,
			"replacement": self.replacement,
		})
	
	def __eq__(self, other):
		if isinstance(other, Smilie):
			return(self.replacement == other.replacement)
		elif isinstance(other, BeautifulSoup.Tag):
			try:
				return(other.name == "img" and other["src"] == self.url and other["title"] == self.title)
			except KeyError:
				return(False)
		elif isinstance(other, str):
			return(self.replacement == other)
		else:
			return(False)
	
	def __ne__(self, other):
		return(not self.__eq__(other))
	
	def __str__(self):
		return(self.replacement)
	
	def __repr__(self):
		return("<Smilie \"{self.replacement}\">".format(self=self))
